跳到主要内容

OpenGL 基本概念之 Shader 学习

本篇笔记的代码基于上篇笔记的 代码 进行拓展

基本语法就不再重复的写了,这里直接参考 GLSL 详解(基础篇)

这里就介绍一个重组的语法,这个重组可以把一些已有的变量重新组装成新的变量

vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;
// Or
vec2 vect = vec2(0.5, 0.7);
vec4 result = vec4(vect, 0.0, 0.0);
vec4 otherResult = vec4(result.xyz, 1.0);

如果单独为 Shader 创建一个文件,一般是以下拓展名

  • 片段着色器 .frag
  • 顶点着色器 .vert

一个典型的着色器有下面的结构:

#version version_number  // 声明版本
in type in_variable_name; // 输入和输出变量
in type in_variable_name;

out type out_variable_name;

uniform type uniform_name;

// 每个着色器的入口点都是main函数
int main()
{
// 处理输入并进行一些图形操作
...
// 输出处理过的结果到输出变量
out_variable_name = weird_stuff_we_processed;
}

顶点着色器的每个输入变量也叫顶点属性(Vertex Attribute),能声明的顶点属性是有上限的,它一般由硬件来决定。

OpenGL 确保至少有 16 个包含 4 分量的顶点属性可用,但是有些硬件或许允许更多的顶点属性,可以查询 GL_MAX_VERTEX_ATTRIBS 来获取具体的上限:

var nrAttributes int32
gl.GetIntegerv(gl.MAX_VERTEX_ATTRIBS, &nrAttributes)
log.Printf("Number of Vertices currently supported: %d", nrAttributes)
// 2022/01/03 11:34:01 Number of Vertices currently supported: 16

Shader 的输入与输出

每个着色器都有输入和输出,这样才能进行数据交流和传递。GLSL 定义了 inout 关键字专门来实现这个目的。

顶点着色器它从顶点数据中直接接收输入,为了定义顶点数据该如何管理,使用 location 这一元数据指定输入变量,这样才可以在 CPU 上配置顶点属性。顶点着色器需要为它的输入提供一个额外的 layout 标识,这样才能把它链接到顶点数据。

// example
layout (location = 0) in vec3 aPos;

然后是片段着色器,它需要一个 vec4 颜色输出变量,因为片段着色器需要生成一个最终输出的颜色。如果在片段着色器没有定义输出颜色,OpenGL会把你的物体渲染为黑色(或白色)。

例如:让顶点着色器为片段着色器决定颜色

顶点着色器

#version 330 core
layout (location = 0) in vec3 aPos; // 位置变量的属性位置值为0

out vec4 vertexColor; // 为片段着色器指定一个颜色输出

void main()
{
gl_Position = vec4(aPos, 1.0); // 注意我们如何把一个vec3作为vec4的构造器的参数
vertexColor = vec4(0.5, 0.0, 0.0, 1.0); // 把输出变量设置为暗红色
}

片段着色器

#version 330 core
out vec4 FragColor;

in vec4 vertexColor; // 从顶点着色器传来的输入变量(名称相同、类型相同)

void main()
{
FragColor = vertexColor;
}

输出效果:

看看能否从应用程序中直接给片段着色器发送一个颜色!

Uniform 发送数据

Uniform 是一种从 CPU 中的应用向 GPU 中的着色器发送数据的方式,但 uniform 和顶点属性有些不同。

首先,uniform 是全局的(Global)。全局意味着 uniform 变量必须在每个着色器程序对象中都是独一无二的,而且它可以被着色器程序的任意着色器在任意阶段访问。第二,无论你把 uniform 值设置成什么,uniform 会一直保存它们的数据,直到它们被重置或更新。

通过 uniform 设置三角形的颜色:

#version 330 core
out vec4 FragColor;

uniform vec4 ourColor; // 在OpenGL程序代码中设定这个变量

void main()
{
FragColor = ourColor;
}

因为 uniform 是全局变量,我们可以在任何着色器中定义它们,而无需通过顶点着色器作为中介。所以这里定义在片段着色器里面

如果声明了一个 uniform 却在 GLSL 代码中没用过,编译器会静默移除这个变量,导致最后编译出的版本中并不会包含它,这可能导致几个非常麻烦的错误,记住这点!

首先需要找到着色器中 uniform 属性的索引/位置值。当得到 uniform 的索引/位置值后,我们就可以更新它的值了。这次我们不去给像素传递单独一个颜色,而是让它随着时间改变颜色:

timeValue := glfw.GetTime()
greenValue := float32((math.Sin(timeValue) / 2.0) + 0.5)
// \x00 表示 ascii 码为 0 的字符,这里用来表示字符串的结尾
vertexColorLocation := gl.GetUniformLocation(program, gl.Str("ourColor" + "\x00"))
gl.UseProgram(program)

gl.Uniform4f(vertexColorLocation, 0.0, greenValue, 0.0, 1.0)

首先我们通过 glfw.GetTime() 获取运行的秒数。然后我们使用 sin 函数让颜色在 0.0 到 1.0 之间改变,最后将结果储存到 greenValue 里。

接着,我们用 gl.GetUniformLocation 查询 uniform ourColor 的位置值,如果返回 -1 就代表没有找到这个位置值。

最后,我们可以通过 Uniform4f 函数设置 uniform 值(因为这里是改变 vec4 的值所以使用 Uniform4f,如果是 vec3 就使用 Uniform3f,依次类推)。

注意,查询 uniform 地址不要求你之前使用过着色器程序,但是更新一个 uniform 之前你必须先使用程序,因为它是在当前激活的着色器程序中设置 uniform 的。

假如我们打算为每个顶点设置一个颜色的时候该怎么办?在这一问题上更好的解决方案是在顶点属性中包含更多的数据

为顶点添加更多属性

如下,为每个顶点添加颜色属性

var (
vertices = []float32{
// 位置 // 颜色
0.5, -0.5, 0.0, 1.0, 0.0, 0.0, // 右下
-0.5, -0.5, 0.0, 0.0, 1.0, 0.0, // 左下
0.0, 0.5, 0.0, 0.0, 0.0, 1.0, // 顶部
}

indices = []uint32{ // 注意索引从0开始!
0, 1, 2, // 第一个三角形
}
)

由于现在有更多的数据要发送到顶点着色器,我们有必要去调整一下顶点着色器,使它能够接收颜色值作为一个顶点属性输入。需要注意的是我们用 layout 标识符来把 aColor 属性的位置值设置为 1:

#version 330 core
layout (location = 0) in vec3 aPos; // 位置变量的属性位置值为 0
layout (location = 1) in vec3 aColor; // 颜色变量的属性位置值为 1

out vec3 ourColor; // 向片段着色器输出一个颜色

void main()
{
gl_Position = vec4(aPos, 1.0);
ourColor = aColor; // 将ourColor设置为我们从顶点数据那里得到的输入颜色
}

由于我们不再使用 uniform 来传递片段的颜色了,现在使用 ourColor 输出变量,我们必须再修改一下片段着色器:

#version 330 core
out vec4 FragColor;
in vec3 ourColor;

void main()
{
FragColor = vec4(ourColor, 1.0);
}

因为我们添加了另一个顶点属性,并且更新了 VBO 的内存,我们就必须重新配置顶点属性指针。更新后的 VBO 内存中的数据现在看起来像这样:

知道了现在使用的布局,我们就可以使用 VertexAttribPointer 函数更新顶点格式

// 4. 设定顶点属性指针
gl.VertexAttribPointerWithOffset(0, 3, gl.FLOAT, false, 6 * 4, 0) // VertexAttribPointer 偏移已经被废弃
gl.EnableVertexAttribArray(0)

gl.VertexAttribPointerWithOffset(1, 3, gl.FLOAT, false, 6 * 4, 3*4)
gl.EnableVertexAttribArray(1)

由于我们现在有了两个顶点属性,我们不得不重新计算步长值。为获得数据队列中下一个属性值(比如位置向量的下个 x 分量)我们必须向右移动 6 个float,其中 3 个是位置值,另外 3 个是颜色值。这使我们的步长值为 6 乘以 float32 的字节数(=24字节)。

注意:VertexAttribPointer 偏移在 1.14 之后已经被废弃

// 1.14 before
gl.VertexAttribPointer(1, 3, gl.FLOAT, false, 6*4, gl.PtrOffset(3*4))
// after
gl.VertexAttribPointerWithOffset(1, 3, gl.FLOAT, false, 6 * 4, 3*4)

最后输出:

到目前为止的 代码

简单的封装 Shader 工具

到目前为止 Shader 读取,数据绑定等等都在一个文件里面,不利于后面的拓展学习,所以这里将其拆分开来,以便直接在硬盘里面读取 Shader 数据

首先创建一个 gfx/shader.go 文件

定义结构体

为它们定义一个 Shader 结构,及进程结构

package gfx

type Shader struct {
handle uint32
}

type Program struct {
handle uint32
shaders []*Shader
}

封装异常消息工具

再封装一个通用的取异常消息的函数

// 取得当前 handle 对象的入口函数
type getObjIv func(uint32, uint32, *int32)

// 取得 Log 的入口函数
type getObjInfoLog func(uint32, int32, *int32, *uint8)

// getGlError
// @Description: 取得错误消息
// @param glHandle 当前操作对象的 ID
// @param checkTrueParam 当前正在执行的操作
// @param getObjIvFn 取得当前 handle 对象的入口函数
// @param getObjInfoLogFn 取得 Log 的入口函数
// @param failMsg 错误消息
// @return error
//
func getGlError(
glHandle uint32,
checkTrueParam uint32,
getObjIvFn getObjIv,
getObjInfoLogFn getObjInfoLog,
failMsg string) error {

var success int32
getObjIvFn(glHandle, checkTrueParam, &success)

if success == gl.FALSE {
var logLength int32
getObjIvFn(glHandle, gl.INFO_LOG_LENGTH, &logLength)

log := gl.Str(strings.Repeat("\x00", int(logLength)))
getObjInfoLogFn(glHandle, logLength, nil, log)

return fmt.Errorf("%s: %s", failMsg, gl.GoStr(log))
}

return nil
}

使用例:

handle := gl.CreateShader(sType)
// ...
err := getGlError(handle, gl.COMPILE_STATUS, gl.GetShaderiv, gl.GetShaderInfoLog,
"SHADER::COMPILE_FAILURE::")
if err != nil {
panic(err)
}

定义 Shader 操作行为

主要是操作这个进程结构为主

func (shader *Shader) Delete() {
gl.DeleteShader(shader.handle)
}

func (prog *Program) Delete() {
for _, shader := range prog.shaders {
shader.Delete()
}
gl.DeleteProgram(prog.handle)
}

func (prog *Program) Attach(shaders ...*Shader) {
for _, shader := range shaders {
gl.AttachShader(prog.handle, shader.handle)
prog.shaders = append(prog.shaders, shader)
}
}

func (prog *Program) SetUniformF4(name string, v1, v2, v3, v4 float32) {
location := gl.GetUniformLocation(prog.handle, gl.Str(name+"\x00"))
gl.Uniform4f(location, v1, v2, v3, v4)
}

func (prog *Program) Use() {
gl.UseProgram(prog.handle)
}

func (prog *Program) Link() error {
gl.LinkProgram(prog.handle)
return getGlError(prog.handle, gl.LINK_STATUS, gl.GetProgramiv, gl.GetProgramInfoLog,
"PROGRAM::LINKING_FAILURE")
}

创建工厂函数

最后是添加一个工厂函数,里面逻辑也很简单,就是

func NewProgram(shaders ...*Shader) (*Program, error) {
prog := &Program{handle: gl.CreateProgram()}
prog.Attach(shaders...)

if err := prog.Link(); err != nil {
return nil, err
}

return prog, nil
}

func NewShader(src string, sType uint32) (*Shader, error) {
handle := gl.CreateShader(sType)
glSrc, freeFn := gl.Strs(src + "\x00")
defer freeFn()
gl.ShaderSource(handle, 1, glSrc, nil)
gl.CompileShader(handle)
err := getGlError(handle, gl.COMPILE_STATUS, gl.GetShaderiv, gl.GetShaderInfoLog,
"SHADER::COMPILE_FAILURE::")
if err != nil {
return nil, err
}
return &Shader{handle: handle}, nil
}

这里再补充一个从文件里面读取 Shader 的工具类

func NewShaderFromFile(file string, sType uint32) (*Shader, error) {
src, err := ioutil.ReadFile(file)
if err != nil {
return nil, err
}

handle := gl.CreateShader(sType)
glSrc, freeFn := gl.Strs(string(src) + "\x00")
defer freeFn()
gl.ShaderSource(handle, 1, glSrc, nil)
gl.CompileShader(handle)
err = getGlError(handle, gl.COMPILE_STATUS, gl.GetShaderiv, gl.GetShaderInfoLog,
"SHADER::COMPILE_FAILURE::" + file)
if err != nil {
return nil, err
}
return &Shader{handle:handle}, nil
}

然后可以创建对应的着色器文件了

  • 片段着色器 .frag
  • 顶点着色器 .vert

简单使用

最后代码可以 这里 找到

References

OpenGL Golang Samples learnopengl 着色器